本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!
上一篇已經處理好註冊與登入的部分,但一個完整的帳戶機制還需要包含 登入後 的身份識別,為什麼登入後還要做身份識別呢?試想今天如果只有註冊與登入功能的話,當使用者登入後要在系統上使用某個會員功能時,該如何辨識的這個使用者是誰呢?要實作這樣的識別功能有很多種做法,Token 正是其中一個被廣泛運用的方案。
Token 就是一個用來表示身份的媒介,當使用者成功登入時,系統會產生出一個獨一無二的 Token,並將該 Token 返回給使用者,只要在 Token 有效的期間內,該使用者在請求中帶上該 Token,系統便會識別出此操作的使用者是誰。
在近幾年有一項 Token 技術非常熱門,其名為 Json Web Token (簡稱:JWT),本篇的身份識別就會用 JWT 來實作!
JWT 是一種較新的 Token 設計方法,它最大的特點是可以在 Token 中含有使用者資訊,不過僅限於較不敏感的內容,比如:使用者名稱、性別等,原因是 JWT 是用 Base64 進行編碼,使用者資訊可以透過 Base64 進行 還原,使用上需要特別留意!
一個 JWT 的格式如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkhBTyIsImFkbWluIjp0cnVlLCJpYXQiOjE1MTYyMzkwMjJ9.d704zBOIq6KNcexbkfBTS5snNa9tXz-RXo7Wi4Xf6RA
會發現整個字串被兩個「.」切割成三段,這三段可以透過 Base64 進行解碼,它們各自有不同的內容:
標頭為 JWT 第一段的部分,其內容包含「加密演算法」與「Token 類型」。上方 JWT 的標頭進行解碼可以得出下方資訊:
{
"alg": "HS256",
"typ": "JWT"
}
內容為 JWT 第二段的部分,這裡通常會放一些簡單的使用者資訊。上方 JWT 的內容進行解碼可以得出下方資訊:
{
"sub": "1234567890",
"name": "HAO",
"admin": true,
"iat": 1516239022
}
簽章為 JWT 第三段的部分,用來防止被竄改,在後端需要維護一組密鑰來替 JWT 進行簽章,密鑰需要妥善保存避免被有心人士獲取!
在開始實作之前,先透過 npm
安裝 JWT 所需的套件,主要有 Nest 包裝的模組、passport-jwt 以及其型別定義檔:
$ npm install @nestjs/jwt passport-jwt
$ npm install @types/passport-jwt -D
首先,我們要先定義一組密鑰來進行 JWT 的簽章,並將該密鑰放至 .env
中:
JWT_SECRET=YOUR_SECRET
接著,在 src/config
資料夾下新增 secret.config.ts
,將密鑰類型的環境變數整合至 secrets
底下:
import { registerAs } from '@nestjs/config';
export default registerAs('secrets', () => {
const jwt = process.env.JWT_SECRET;
return { jwt };
});
在 app.module.ts
中進行套用:
import { APP_PIPE } from '@nestjs/core';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './features/user/user.module';
import { AuthModule } from './features/auth/auth.module';
import MongoConfigFactory from './config/mongo.config';
import SecretConfigFactory from './config/secret.config';
@Module({
imports: [
ConfigModule.forRoot({
load: [MongoConfigFactory, SecretConfigFactory], // 套用至 ConfigModule
isGlobal: true
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>('mongo.uri'),
}),
}),
UserModule,
AuthModule,
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_PIPE,
useClass: ValidationPipe,
},
],
})
export class AppModule {}
完成密鑰的配置後,就來配置 JWT 吧!我們在處理驗證的 AuthModule
中匯入 JwtModule
,並使用 registerAsync
方法來配置 JWT 的設定,最重要的就是將密鑰帶入:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { UserModule } from '../user';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';
@Module({
imports: [
PassportModule,
UserModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const secret = config.get('secrets.jwt');
return {
secret,
signOptions: {
expiresIn: '60s'
}
};
},
})
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy],
})
export class AuthModule {}
注意:本篇主要是實作一個簡單的身分識別功能,所以詳細的
JwtModule
配置項請參考 官方文件 以及 node-jsonwebtoken。
上一篇我們是讓使用者登入後獲得使用者資料,這篇我們將會把這個機制更換成回傳 JWT,讓使用者可以順利拿到它來使用會員功能,所以我們要在 AuthService
設計一個 generateJwt
方法來調用 JwtService
的 sign
方法產生 JWT,該方法需要帶入要放在「內容」區塊的資料,這裡我們就放入使用者的 id
與 username
:
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { CommonUtility } from '../../core/utils/common.utility';
import { UserDocument } from '../../common/models/user.model';
import { UserService } from '../user';
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly userService: UserService,
) {}
async validateUser(username: string, password: string) {
const user = await this.userService.findUser({ username });
const { hash } = CommonUtility.encryptBySalt(
password,
user?.password?.salt,
);
if (!user || hash !== user?.password?.hash) {
return null;
}
return user;
}
generateJwt(user: UserDocument) {
const { _id: id, username } = user;
const payload = { id, username };
return {
access_token: this.jwtService.sign(payload),
};
}
}
我們上一篇在 LocalStrategy
設定回傳值只有 username
與 email
,這不符合我們產生 JWT 所需的資料,所以改成直接回傳整個使用者資料:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super();
}
async validate(username: string, password: string) {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
注意:
validate
最好是只回傳重點資料。
最後就是在 AuthController
的 signin
方法回傳 generateJwt
的結果:
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
import { UserDocument } from '../../common/models/user.model';
import { CreateUserDto, UserService } from '../user';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly userService: UserService,
private readonly authService: AuthService,
) {}
@Post('/signup')
signup(@Body() user: CreateUserDto) {
return this.userService.createUser(user);
}
@UseGuards(AuthGuard('local'))
@Post('/signin')
signin(@Req() request: Request) {
return this.authService.generateJwt(request.user as UserDocument);
}
}
透過 Postman 進行登入測試,成功的話會獲得 access_token
:
接下來我們要製作 JwtStrategy
與 passport
進行串接,跟 LocalStrategy
的實作方式大同小異,必須繼承 passport-jwt
的 strategy
,比較不同的地方在於 super
帶入的參數。我們先在 src/features/auth/strategies
資料夾下新增 jwt.strategy.ts
:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('secrets.jwt'),
});
}
validate(payload: any) {
const { id, username } = payload;
return { id, username };
}
}
可以看到 super
帶入了三個參數:
jwtFromRequest
:指定從請求中的哪裡提取 JWT,這裡可以使用 ExtractJwt
來輔助配置。ignoreExpiration
:是否忽略過期的 JWT,預設是 false
。secretOrKey
:放入 JWT 簽章用的密鑰。注意:更多的參數內容請參考 官方文件。
可以注意一下 validate
這個方法,基本上 JWT 在流程上就已經驗證了其合法性與是否過期,故這裡 可以不用 進行額外的檢查,但如果要在這裡向資料庫提取更多的使用者資訊也是可以的。
完成 JwtStrategy
後記得要在 AuthModule
的 providers
裡面添加它:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { UserModule } from '../user';
import { AuthService } from './auth.service';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule,
UserModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const secret = config.get('secrets.jwt');
return {
secret,
signOptions: {
expiresIn: '60s'
}
};
},
})
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}
最後,我們設計一個取得使用者資料的 API 來套用 JWT 驗證,透過 CLI 產生 UserController
:
$ nest generate controller features/user
然後修改一下 user.controller.ts
的內容:
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UserService } from './user.service';
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@UseGuards(AuthGuard('jwt'))
@Get(':id')
async getUser(@Param('id') id: string) {
const user = await this.userService.findUser({ _id: id });
const { password, ...others } = user.toJSON();
return others;
}
}
在 getUser
方法套用 AuthGuard
並指定使用 jwt
策略,將傳入的 id
向資料庫進行查詢,取得 UserDocument
後,先把它轉換成 JSON 格式,再透過解構的方式將 password
以外的屬性回傳到客戶端。
先透過 Postman 進行登入取得 access_token
,並將其帶入 Bearer token
中來測試取得使用者資料的 API:
如果帶入過期或是錯誤的 JWT 則會收到下方錯誤訊息:
這兩天的內容實現了一套簡單的本地身份驗證機制,相信大家已經了解 passport
的概念與使用方式了,有興趣的讀者可以嘗試串接 Facebook 或 Google 的驗證機制。這裡附上今天的懶人包:
JwtModule
配置密鑰、期限等參數,主要是用來建立 JWT。JwtStrategy
的 super
需要指定一些參數,主要是用來驗證 JWT 的。